﻿// -----------------------------------------------------------------------
// <copyright file="ProxyServer.cs" company="TaskyMedia LLC">
//  Copyright (c) TaskyMedia LLC
// </copyright>
// -----------------------------------------------------------------------

namespace SohpServer
{
    using System;
    using System.Collections.Concurrent;
    using System.Diagnostics;
    using System.Diagnostics.CodeAnalysis;
    using System.Globalization;
    using System.Linq;
    using System.Net;
    using System.Text;
    using System.Threading;

    /// <summary>
    /// Acts as a SOHP Proxy server for clients that respect the SOHP protocol.
    /// </summary>
    public class ProxyServer : IDisposable
    {
        #region Constants

        /// <summary>
        /// Specifies that the maximum number of clients is undefined.
        /// </summary>
        public const int MaxClientsInfinite = -1;

        /// <summary>
        /// The version of the SOHP protocol implemented by the server.
        /// </summary>
        public const string ProtocolVersion = "1.0";

        /// <summary>
        /// Protocol versions supported by the current proxy server version.
        /// </summary>
        private static readonly string[] SupportedProtocolVersions = { "1.0" };

        /// <summary>
        /// A list of operation names that are reserved and cannot be used to register custom content handlers.
        /// </summary>
        private static readonly string[] ReservedActions = { "open", "close", "send", "receive" };

        #endregion

        #region Fields

        /// <summary>
        /// The starting path of the URI at which the server will be listening. 
        /// </summary>
        private string pathPrefix;

        /// <summary>
        /// The maximum number of clients the server should support at once.
        /// </summary>
        private int maxClients;

        /// <summary>
        /// The current number of clients connected to the server.
        /// </summary>
        private int currentClientCount;

        /// <summary>
        /// The current connection identifier; these are not recycled.
        /// </summary>
        private int currentConnectionId;

        /// <summary>
        /// The actual <see cref="HttpListener"/> used to receive and process proxy server commands.
        /// </summary>
        private HttpListener httpServer;

        /// <summary>
        /// List of open proxy connections.
        /// </summary>
        private ConcurrentDictionary<int, Connection> connections;

        /// <summary>
        /// Dictionary of custom content handler methods.
        /// </summary>
        private ConcurrentDictionary<string, Action<HttpListenerContext>> customContentHandlers;

        #endregion

        #region Constructors

        /// <summary>
        /// Initializes a new instance of the <see cref="ProxyServer"/> class.
        /// </summary>
        /// <param name="pathPrefix">The starting path of the URI at which the server
        /// will be listening. </param>
        /// <remarks>
        /// The path prefix specifies the scheme (http or https), the host name, the port, 
        /// and an optional path. For example, http://server/ or https://server/sohp/ are 
        /// acceptable prefixes. For HTTPS, the certificate to be used must be set up using 
        /// the httpcfg or netsh utilities (depending on the host operating system version).
        /// </remarks>
        public ProxyServer(string pathPrefix)
        {
            this.pathPrefix = pathPrefix;
            this.customContentHandlers = new ConcurrentDictionary<string, Action<HttpListenerContext>>();
        }

        #endregion

        #region Public Methods

        /// <summary>
        /// Starts the SOHP proxy server.
        /// </summary>
        /// <param name="maxConcurrentClients">The maximum number of concurrent clients.</param>
        public void StartServer(int maxConcurrentClients)
        {
            this.maxClients = maxConcurrentClients;
            this.connections = new ConcurrentDictionary<int, Connection>();
            this.httpServer = new HttpListener();
            this.httpServer.Prefixes.Add(this.pathPrefix);
            this.httpServer.Start();
            this.httpServer.BeginGetContext(this.OnGetContextCompleted, null);
        }

        /// <summary>
        /// Adds a custom handler to serve content for a specific operation request.
        /// </summary>
        /// <param name="operationName">The name of the operation.</param>
        /// <param name="targetAction">The method to invoke that is responsible for serving the content.</param>
        public void AddCustomContentHandler(string operationName, Action<HttpListenerContext> targetAction)
        {
            if (string.IsNullOrEmpty(operationName))
            {
                throw new ArgumentNullException("operationName", "Operation name cannot be null");
            }

            string operationKey = operationName.ToUpperInvariant();

            if (ProxyServer.ReservedActions.Contains(operationKey))
            {
                throw new ArgumentException(string.Format(CultureInfo.InvariantCulture, "'{0}' is a reserved operation", operationKey));
            }

            if (this.customContentHandlers.ContainsKey(operationKey))
            {
                throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, "'{0}' is already a registered operation", operationKey));
            }

            if (!this.customContentHandlers.TryAdd(operationKey, targetAction))
            {
                throw new InvalidOperationException("Unable to add operation; unknown failure");
            }
        }

        /// <summary>
        /// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
        /// </summary>
        public void Dispose()
        {
            Dispose(true);
            GC.SuppressFinalize(this);
        }

        #endregion

        #region Internal Static Methods

        /// <summary>
        /// Sends the OK response.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void SendOKResponse(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 200;
            reqContext.Response.StatusDescription = "OK";
            reqContext.Response.Close();
        }

        /// <summary>
        /// Sends the syntax error response back to the requestor.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void SendSyntaxErrorResponse(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 400;
            reqContext.Response.StatusDescription = "Bad Request";
            reqContext.Response.Close();
        }

        /// <summary>
        /// Sends the socket not found response back to the requestor.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void SendSocketNotFoundResponse(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 410;
            reqContext.Response.StatusDescription = "Socket Connection Gone";
            reqContext.Response.Close();
        }

        /// <summary>
        /// Sends the internal server error response back to the requestor.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void SendInternalServerErrorResponse(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 500;
            reqContext.Response.StatusDescription = "Internal Server Error";
            try
            {
                reqContext.Response.Close();
            }
            catch (InvalidOperationException)
            {
                // Do nothing -- sending HTTP/500 is best-effort
            }
        }

        /// <summary>
        /// Sends the capacity reached response back to the requestor.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void SendCapacityReachedResponse(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 503;
            reqContext.Response.StatusDescription = "Service Unavailable : Over Capacity";
            reqContext.Response.Close();
        }

        /// <summary>
        /// Sends the connection failed response back to the requestor.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void SendConnectionFailedResponse(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 504;
            reqContext.Response.StatusDescription = "Gateway Timeout : Could not establish connection to host";
            reqContext.Response.Close();
        }

        /// <summary>
        /// Sends the unsupported version response back to the requestor.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void SendUnsupportedVersionResponse(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 505;
            reqContext.Response.StatusDescription = "SOHP Version Not Supported";
            string outputMessage = string.Format(CultureInfo.InvariantCulture, "Supported client versions: {0}", string.Join(",", ProxyServer.SupportedProtocolVersions));
            byte[] outputMessageData = Encoding.Default.GetBytes(outputMessage);
            reqContext.Response.OutputStream.Write(outputMessageData, 0, outputMessageData.Length);
            reqContext.Response.Close();
        }

        /// <summary>
        /// Dumps the error HTML back to the requestor.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        internal static void DumpErrorHtml(HttpListenerContext reqContext)
        {
            reqContext.Response.StatusCode = 405;
            reqContext.Response.StatusDescription = "Method Not Allowed";
            byte[] output = Encoding.Default.GetBytes(SohpServer.Properties.Resources.SOHPDefault.Replace("{VERSION}", ProxyServer.ProtocolVersion));
            reqContext.Response.OutputStream.Write(output, 0, output.Length);
            reqContext.Response.Close();
        }

        #endregion

        #region IDisposable Implementation

        /// <summary>
        /// Releases unmanaged and - optionally - managed resources
        /// </summary>
        /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; 
        /// <c>false</c> to release only unmanaged resources.</param>
        protected virtual void Dispose(bool disposing)
        {
            if (disposing)
            {
                // free managed resources
                if (this.httpServer != null)
                {
                    ((IDisposable)this.httpServer).Dispose();
                    this.httpServer = null;
                }
            }
        }

        #endregion

        #region Private Methods

        /// <summary>
        /// Called when the <c>BeginGetContext</c> of the <see cref="HttpListener"/> completes.
        /// </summary>
        /// <param name="asyncResult">The <see cref="IAsyncResult"/> representing the result.</param>
        private void OnGetContextCompleted(IAsyncResult asyncResult)
        {
            HttpListenerContext reqContext = this.httpServer.EndGetContext(asyncResult);
            this.httpServer.BeginGetContext(this.OnGetContextCompleted, null);

            reqContext.Response.AddHeader("X-SOHP-Version", ProtocolVersion);
            reqContext.Response.Headers[HttpResponseHeader.Server] = string.Format(CultureInfo.InvariantCulture, "SOHP-Proxy/{0}", ProxyServer.ProtocolVersion);

            string operationName = reqContext.Request.Url.AbsolutePath.Substring(reqContext.Request.Url.AbsolutePath.LastIndexOf('/') + 1).ToUpperInvariant();

            if (ProxyServer.ReservedActions.Contains(operationName))
            {
                string clientSohpVer = reqContext.Request.Headers["X-SOHP-ClientVersion"];
                if (string.IsNullOrEmpty(clientSohpVer))
                {
                    ProxyServer.DumpErrorHtml(reqContext);
                }
                else if (!ProxyServer.SupportedProtocolVersions.Contains(clientSohpVer))
                {
                    ProxyServer.SendUnsupportedVersionResponse(reqContext);
                }
                else
                {
                    if (operationName == "OPEN")
                    {
                        this.OnOpenRequest(reqContext);
                    }
                    else if (operationName == "CLOSE" || operationName == "SEND" || operationName == "RECEIVE")
                    {
                        this.OnOperationRequest(reqContext);
                    }
                    else
                    {
                        ProxyServer.DumpErrorHtml(reqContext);
                    }
                }
            }
            else if (this.customContentHandlers.ContainsKey(operationName))
            {
                this.customContentHandlers[operationName].Invoke(reqContext);
            }
            else
            {
                ProxyServer.DumpErrorHtml(reqContext);
            }
        }

        /// <summary>
        /// Attempts to increment the client count.
        /// </summary>
        /// <returns>Returns <c>false</c> if the maximum number of clients has been reached;
        /// otherwise, returns <c>true</c>.</returns>
        private bool TryIncrementClientCount()
        {
            int initialTotal, newTotal;

            do
            {
                initialTotal = this.currentClientCount;
                newTotal = initialTotal + 1;

                if (this.maxClients != ProxyServer.MaxClientsInfinite && newTotal > this.maxClients)
                {
                    return false;
                }
            } while (initialTotal != Interlocked.CompareExchange(ref this.currentClientCount, newTotal, initialTotal));

            return true;
        }

        /// <summary>
        /// Decrements the client count.
        /// </summary>
        private void DecrementClientCount()
        {
            Interlocked.Decrement(ref this.currentClientCount);
        }

        /// <summary>
        /// Gets the next unique, available connection id.
        /// </summary>
        /// <returns>Returns the next available unique connection id.</returns>
        private int GetNextConnectionId()
        {
            return Interlocked.Increment(ref this.currentConnectionId);
        }

        #region Request Handlers

        /// <summary>
        /// Called when a request to open a new connection is received.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        [SuppressMessage("Microsoft.Reliability", "CA2000:Dispose objects before losing scope", Justification = "Connection is stored in a collection and disposed of elsewhere")]
        private void OnOpenRequest(HttpListenerContext reqContext)
        {
            bool needRollback = false;

            try
            {
                if (this.TryIncrementClientCount())
                {
                    needRollback = true;

                    // Validate the query parameters
                    string host = reqContext.Request.QueryString["hostName"];
                    string portRaw = reqContext.Request.QueryString["port"];
                    ushort port = 0;

                    if (string.IsNullOrEmpty(host) || string.IsNullOrEmpty(portRaw) || !ushort.TryParse(portRaw, out port) || port < 0)
                    {
                        ProxyServer.SendSyntaxErrorResponse(reqContext);
                    }
                    else
                    {
                        // Attempt to establish a connection
                        Connection proxyConnection = new Connection();
                        proxyConnection.ConnectionClosed += new EventHandler(this.OnProxyConnectionClosed);

                        if (proxyConnection.Connect(host, port))
                        {
                            int connectionId = this.GetNextConnectionId();
                            long connectionSecret = RandomHelper.Next();
                            long adjustedTimeStamp = DateTime.UtcNow.Ticks;

                            proxyConnection.SetConnectionParams(connectionId, connectionSecret, adjustedTimeStamp);
                            if (this.connections.TryAdd(connectionId, proxyConnection))
                            {
                                reqContext.Response.AddHeader("X-SOHP-ConnectionID", connectionId.ToString(CultureInfo.InvariantCulture));
                                reqContext.Response.AddHeader("X-SOHP-ConnectionSecret", connectionSecret.ToString(CultureInfo.InvariantCulture));
                                reqContext.Response.AddHeader("X-SOHP-ATS", adjustedTimeStamp.ToString(CultureInfo.InvariantCulture));
                                reqContext.Response.StatusCode = 200;
                                reqContext.Response.StatusDescription = "OK";
                                reqContext.Response.Close();
                            }
                            else
                            {
                                throw new InvalidOperationException("Failed to add connection to dictionary");
                            }
                        }
                        else
                        {
                            ProxyServer.SendConnectionFailedResponse(reqContext);
                        }
                    }
                }
                else
                {
                    ProxyServer.SendCapacityReachedResponse(reqContext);
                }
            }
            catch (Exception ex)
            {
                Trace.TraceError("An error occured within ProxyServer.OnOpenRequest(HttpListenerContext): \r\n\r\n{0}", ex);
                ProxyServer.SendInternalServerErrorResponse(reqContext);
                throw;
            }
            finally
            {
                if (needRollback)
                {
                    // Release the client reservation from the pool
                    this.DecrementClientCount();
                }
            }
        }

        /// <summary>
        /// Handles the ConnectionClosed event of the <see cref="Connection"/> object.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="e">The <see cref="System.EventArgs"/> instance containing the event data.</param>
        private void OnProxyConnectionClosed(object sender, EventArgs e)
        {
            Connection toRemove = sender as Connection;
            toRemove.ConnectionClosed -= this.OnProxyConnectionClosed;
            this.connections.TryRemove(toRemove.ConnectionId, out toRemove);
        }

        /// <summary>
        /// Called when an operation on an existing connection is requested.
        /// </summary>
        /// <param name="reqContext">The request context.</param>
        private void OnOperationRequest(HttpListenerContext reqContext)
        {
            try
            {
                int connectionId = 0;
                long adjustedTimeout = 0;
                string signature;

                if (int.TryParse(reqContext.Request.Headers["X-SOHP-ConnectionID"], out connectionId) &&
                    long.TryParse(reqContext.Request.Headers["X-SOHP-ATS"], out adjustedTimeout) &&
                    !string.IsNullOrEmpty(signature = reqContext.Request.Headers["X-SOHP-RequestSignature"]))
                {
                    // TODO: Verify the signature.
                    Connection activeConnection;
                    if (this.connections.TryGetValue(connectionId, out activeConnection))
                    {
                        activeConnection.HandleOperationRequest(reqContext, adjustedTimeout, signature);
                    }
                    else
                    {
                        ProxyServer.SendSocketNotFoundResponse(reqContext);
                    }
                }
                else
                {
                    ProxyServer.SendSyntaxErrorResponse(reqContext);
                }
            }
            catch (HttpListenerException ex)
            {
                Trace.TraceError("An error occured within ProxyServer.OnOperationRequest(HttpListenerContext): \r\n\r\n{0}", ex);
                ProxyServer.SendInternalServerErrorResponse(reqContext);
            }
        }

        #endregion

        #endregion
    }
}
